Skip to content

fix(utils): capture entry serial in List.push disposer - #71

Open
buiducnhat wants to merge 1 commit into
cordiverse:mainfrom
buiducnhat:fix/utils-list-disposer-serial
Open

fix(utils): capture entry serial in List.push disposer#71
buiducnhat wants to merge 1 commit into
cordiverse:mainfrom
buiducnhat:fix/utils-list-disposer-serial

Conversation

@buiducnhat

Copy link
Copy Markdown

Problem

@cordisjs/utils's List.push() registers an effect whose disposer closes over this.sn — the mutable field — instead of the serial number of the entry it created:

push(value: T) {
  this.ctx.effect(() => {
    this.inner.set(++this.sn, value)
    return () => this.inner.delete(this.sn) // reads the *current* sn at dispose time
  }, `${this.trace}.push()`)
}

Once a second entry is pushed, this.sn has advanced, so disposing the first entry removes the most recent one rather than its own.

This is observable on fiber reload: unloading runs the disposers in reverse order, so the first-pushed entry is never removed and gets re-pushed on the next load. list.length grows and iterating yields stale duplicates:

const ctx = new Context()
let list: List<string>
const fiber = ctx.plugin((sub) => {
  list ??= new List(sub, 'test')
  list.push('a')
  list.push('b')
})
await fiber
console.log([...list], list.length) // ['a','b'] 2

fiber.update({})
await fiber
console.log([...list], list.length) // ['a','a','b'] 3  ← stale entry leaked

Fix

Capture the serial number at push time (matching the core DisposableList implementation in packages/core/src/utils.ts), so each disposer removes exactly the entry it created:

push(value: T) {
  this.ctx.effect(() => {
    const sn = ++this.sn
    this.inner.set(sn, value)
    return () => this.inner.delete(sn)
  }, `${this.trace}.push()`)
}

Tests

Added packages/utils/tests/index.spec.ts (the @cordisjs/utils package previously had no tests) covering:

  • basic push + iteration/length
  • no stale-entry leak across fiber reloads

Verified the regression test fails on main (['a','a','b']) and passes with the fix. Full suite remains green (165 tests, 20 files).

`List.push()` registers an effect whose disposer closes over `this.sn` — the
*mutable* field — rather than the serial number of the entry it created. When a
second entry is pushed, `this.sn` advances, so disposing the first entry removes
the most recent one instead of its own.

The failure is observable on fiber reload: unloading runs the disposers in
reverse order, so the first-pushed entry is never removed from the map and is
re-pushed on the next load. `list.length` grows and `[...list]` yields stale
duplicates (e.g. `["a", "a", "b"]` after one reload).

Capture the serial number at push time (matching the core `DisposableList`
implementation) so each disposer removes exactly the entry it created.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant